iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
在數學上,有些函數在定義域的某些值會沒有定義,例如,當定義域是所有實數,函數f(x)=1/x,則f(0)是沒有意義。此時,我們會將x=0從我們的定義域剔除;然而,在資訊的世界,我們無法強迫使用者都是給合法的輸入,我們如果還要讓函數在定義域的每個值都能有定義,我們只好將我們原來的對應域加上一個叫做「無意義」的值,成為新的對應域。

Option

NaN

如果我們在python定義下列函數

import math

def sqrt(x):
  return math.sqrt(x)

def decrement(x):
  return x - 1

def reciprocal(x):
  return 1 / x

def f(x):
  return reciprocal(decrement(sqrt(x)))

接著我們分別執行print(f(1))print(f(-3)),我們會分別得到ZeroDivisionError: float division by zero和ValueError: math domain error,這是因為計算f(1)時,我們會先計算sqrt(1)得到1,再計算decrement(1)得到0,再計算reciprocal(0)時,此時分母為0,所以得到ZeroDivisionError;
當計算f(-3)時,sqrt(-3)便會得到math domain error,因為根號運算不能有負值,所以兩者皆為拋出錯誤而中斷程式的執行,造成困擾,我們必須用try except來處理錯誤,讓程式可以繼續進行,不被中斷。
我們如果在typescript中定義相同的函數,

const sqrt = (x: number): number => Math.sqrt(x)
const decrement = (x: number): number => x - 1
const reciprocal = (x: number): number => 1 / x
const f = (x: number): number => reciprocal(decrement(sqrt(x)))

如果我們執行console.log(f(-3)),卻不會拋出錯誤,而是得到NaN,而且程式不會中斷。Javascript的number型別除了一般的數字還有一個很特別的值叫NaN,當我們運算式在數學上沒有定義時,也就是無法計算時,我們便會得到NaN這個值,代表的是「無意義」,這個值不會中斷我們函數的合成,而會無條件傳遞下去;也就是說,一旦合成過程中的任一步驟得到NaN這個值,那最後合成的結果一定是NaN。如此便可以不用使用try catch語法,仍可保持程式的順暢。

至於執行console.log(f(-3))則會得到Infinity,這是number型別另一個特別的值,但它不會無條件傳遞下去,它會以「不定型極限」的規則進行,最後的結果有可能是Infinity或NaN。

在函數式程式設計中,替所有的型別處理都設計了「無意義」的型別建構子,稱之為Maybe或Option,fp-ts中扮演這個角色的型別建構子稱為Option,Option本身不是一種型別,而是型別建構子,必須代入型別參數才能成為型別實體,它由Some型別建構子和None型別聯集而得,它們的定義如下:

interface None {
  readonly _tag: 'None'
}

interface Some<A> {
  readonly _tag: 'Some'
  readonly value: A
}

type Option<A> = None | Some<A>

fp-ts/Option模組提供幾個建構函數,如of、some和fromNullable。of和some是相同的建構函數,of這個名字強調它的一般性,fp-ts的所有容器幾乎都有這個建構子,不需要額外記憶,some只有在Option這個容器才能使用,它們會得到Some的型別。none則是型別為None的常數,fromNullable則是會將undefined和null的輸入轉為None的輸出,其它的輸入則會得到Some的輸出。

import { none, some, fromNullable } from 'fp-ts/Option'
assert.deepStrictEqual(fromNullable(undefined), none)
assert.deepStrictEqual(fromNullable(null), none)
assert.deepStrictEqual(fromNullable(1), some(1))

我們重新修改前面的程式碼,將函數的型別定義獨立成一行,如此比較方便知道他們的輸入與輸出的型別,另外我們將sqrt函數改成safeSqrt,它的輸入依舊是number,但是輸出型別變成Option

import { Option, none, of, map } from 'fp-ts/Option'
import { pipe, flow } from 'fp-ts/function'
type SafeSqrt = (x: number) => Option<number>;
const safeSqrt: SafeSqrt = (x) => (x < 0 ? none : pipe(x, Math.sqrt, some));
type Decrement = (x: number) => number;
const decrement: Decrement = (x) => x - 1;
type Reciprocal = (x: number) => number;
const reciprocal: Reciprocal = (x) => 1 / x;
const f = flow(
  safeSqrt,
  decrement, //Argument of type 'Decrement' is not assignable to parameter of type '(b: Option<number>) => number'.
  reciprocal
)

此時我們若直接合成safeSqrt, decrement, reciprocal這三個程式,typescript將會出現抱怨訊息,因為safeSqrt的輸出型別為Option,decrement的輸入型別卻是number,兩者不同,所以不能合成。Option也是一個Functor,所以Option模組中也提供了map函數,map(decrement)也是一個函數,它的輸入和輸出型別都是Option,map(decrement)這個函數的行為很簡單,如果輸入是None,則輸出也是None;如果輸入是some(value),則輸出是some(decrement(value)),這個特性很像NaN,在整個合成的過程中產生遞延性。同樣地,我們也必須map(reciprocal),修正後的f如下:

const f = flow(
  safeSqrt,
  map(decrement),
  map(reciprocal)
)

此時,f的輸入型別是number, 輸出型別是Option,但是我們希望能取得Some型別內的值,那才是我們需要的。

取值

fp-ts/Option模組內提供了幾個取值的函數,分別是getOrElse、match和matchW,這三者的輸入都是Option型別。

  • getOrElse:
    提一個onNone的回呼函數,這個函數沒有輸入,而它的輸出型別必須是T,如果輸入是None,則會執行這個函數;如果輸入不是None,則輸出Some容器裏的value,它的型別也是T,所以getOrElse的輸出型別一定是T。
  • match
    我們必須提供onNone和onSome兩個回呼函數,它們的輸出型別必須相同,但不必須是T,如果輸入是None,則會執行onNone函數;如果輸入不是None,則執行onSome函數,onSome的輸入有一個參數,型別是T,也就是我們Some容器裏的值。
  • matchW
    matchW和match類似,差別在於onNone和onSome的輸出型別不要求相同。
  // f1 :: number -> number
  const f1 = flow(
    safeSqrt,
    map(decrement),
    map(reciprocal),
    getOrelse(() => NaN) // NaN的型別是number,符合規定
  )

  // f2 :: number -> string
  const f2 = flow(
    safeSqrt,
    map(decrement),
    map(reciprocal),
    match(
      () => '無意義', // 這是onNone函數
      (x) => `輸出的值是${x}` // 這是onSome函數
    )
  )
  // f3 :: number -> number | string
  const f3 = flow(
    safeSqrt,
    map(decrement),
    map(reciprocal),
    match(
      () => '無意義', // 這是onNone函數
      (x) => x // 這是onSome函數
    )
  )

最後,我們再看一個例子:

type SafeHead = <T>(as: T[]) => Option<T>;
const safeHead: SafeHead = (as) => (as.length > 0 ? of(as[0]) : none);

type G = (as: number[]) => string;
const g: G = flow(
  safeHead,
  map(decrement),
  match(
    () => '空陣列',
    (x) => `第一個元素是${x}`
  )
)

safeHead的輸入是一個陣列,因為可能遇到空陣列,讓函數的合成順利,所以函數的輸出設計為Option,函數g限制輸入陣列為number陣列,同樣地,我們必須map(decrement),如此才能合成,最後我們用match輸出都是string,所以函數g的輸出是string。
Option的使用方式大致便是如此,當你的函數可能undefined或null時,你可以設計函數的輸出為Option,中間的處理可以設計函數的輸出和輸入都是typescript型別,但是合成的時候要記得先map,最後再用getOrElse、getOrElseW、match和matchW等函數取值出來。

型別安全的Array函數

Array模組所提供的utils函數大多是具備型別安全,也就是其輸出是Option所建構的型別,例如head函數便和上面所提的safeHead相同,以下列舉分三類列舉這些函數。

1.head、last、tail、init

head函數如前述,last則取最後一個元素,tail則扣除第一個元素剩餘元素所成的陣列,init則扣除最後一個元素剩餘元素所成的陣列;如果輸入的陣列是空陣列,則得到的結果是none。

console.log(init([1, 2, 3])); // some([2, 3])
console.log(init([1])); // some([])
console.log(init([])); // none

console.log(tail([1, 2, 3])); // some([1, 2])
console.log(tail([1])); // some()
console.log(tail([])); // none

2.findFirst、findLast、findIndex、findLastIndex、lookup

findFirst、findLast、findIndex、findLastIndex需要提供一個Predicate<A>型別(即輸入是型別A,輸出是boolean)當作第一個參數,第二個參數則是Array<A>型別,findIndex、findLastIndex的輸出型別是Array<Option<number>>;

const isPositive = (x: number) => x > 0;
console.log(findFirst(isPositive)([-3, 2, -4, 5])); // some(2)
console.log(findIndex(isPositive)([-3, 2, -4, 5])); // some(1)
console.log(findLast(isPositive)([-3, 2, -4, 5])); // some(5)
console.log(findLastIndex(isPositive)([-3, 2, -4, 5])); // som(3)
console.log(findFirst(isPositive)([-3])); // none
console.log(findIndex(isPositive)([-3])); // none
console.log(findLast(isPositive)([-3])); // none
console.log(findLastIndex(isPositive)([-3])); // none

lookup函數則接受一個number型別的參數作為搜尋的註標(index),第二個參數是Array<A>型別,輸出是Option<A>

console.log(lookup(2)([1, 2, 3, 4])); // some(3)
console.log(lookup(2)([])); // none

3.insertAt, updateAt, deleteAT

insertAt的第一個參數是number(index)和型別A(新增的元素)組成的元組(Tuple),第二個參數則是型別A的陣列,回傳型別是Option<Array<A>>。如果第一個參數的註標大於第二個參數陣列的長度則回傳none。

console.log(insertAt(2, 5)([1, 2, 3])); //some([1, 2, 5, 3])
console.log(insertAt(2, 5)([1])); // none

updateAt的第一個參數是number(index)和型別A(新增的元素)組成的元組(Tuple),第二個參數則是型別A的陣列,回傳型別是Option<Array<A>>。如果第一個參數的註標大於或等於第二個參數陣列的長度則回傳none。

console.log(updateAt(2, 5)([1, 2, 3])); //some([1, 2, 5])
console.log(updateAt(1, 5)([1])); // none

deleteAt的第一個參數是number(index),第二個參數則是型別A的陣列,回傳型別是Option<Array<A>>。如果第一個參數的註標大於或等於第二個參數陣列的長度則回傳none。

console.log(deleteAt(1)([1, 2, 3])); //some([1, 3])
console.log(deleteAt(1)([1])); // none

Either

雖然Option Functor可以確保數個函數順利合成,不致於因為null或undefined的輸入值造成整個程式的中斷,由於在合成的許多函數步驟中,我們無法知道是在那一個函數輸出none,無法提供足夠的錯誤訊息。如果希望能夠程式合成又能提供相關的錯誤訊息,那麼就是Either Functor登場的時刻。
我們先看fp-ts中Either型別建構子的定義:

type Either<E, A> = Left<E> | Right<A>

Either這個型別建構子由Left和Right兩個型別建構子聯集而成,分別需要不同的型別參數E和A。Right型別建構子扮演Option中Some型別建構子的角色,保留有意義的值;Right型別建構子則扮演None型別,和None不同的地方在於Right是型別建構子,通常帶入我們定義的錯誤訊息型別,通常我們會固定我們事先定義的錯誤訊息型別E,如此就只需要一個型別參數A。
Either模組提供的基本建構函數有right和left,我們用下面的程式碼來說明:

import { pipe, flow } from 'fp-ts/function';
import { Either, Right, Left, left, right, map, match } from 'fp-ts/Either';

const trace =
  <A>(tag: string) =>
  (x: A): A => {
    console.log(tag, x);
    return x;
  };

type EvaluationError =
  | { type: 'DIVISION'; message: string }
  | { type: 'SQRT'; message: string };

const divisionError: EvaluationError = {
  type: 'DIVISION',
  message: '除數不能為0',
};
const sqrtError: EvaluationError = {
  type: 'SQRT',
  message: '根號裏面不能有負的',
};

type SafeSqrt = (x: number) => Either<EvaluationError, number>;
const safeSqrt: SafeSqrt = (x) =>
  x < 0 ? left(sqrtError) : pipe(x, Math.sqrt, right);
type Decrement = (x: number) => number;
const decrement: Decrement = (x) => x - 1;
type Reciprocal = (x: number) => number;
const reciprocal: Reciprocal = (x) => 1 / x;

const f = flow(
  safeSqrt,
  map(decrement),
  trace('decrement'),
  map(reciprocal),
  match(
    (e) => e.message,
    (x) => `您得到的值是${x}`
  )
);
console.log(f(25)); // 您得到的值是0.25
console.log(f(-3)); // 根號裏面不能有負的
console.log(f(1)); // 您得到的值是Infinity

以上面的例子來看,Either Functor和Option Functor的行為很類似,right和some的部分基本上是一樣的,而left和none一旦出現,都會傳遞下去,兩者的差異在於,left產生的錯誤訊息也會一直傳遞下去。

延伸問題

計算f(1)的時候,我們會得到「您得到的值Infinity」的答案,但是這並不是我們想要的結果,我們希望說得到divisionError的錯誤訊息,如果我們將reciprocal改寫成下面的程式碼safeReciprocal執行,會得到什麼樣的結果?

type SafeReciproca = (x: number) => Either<EvaluationError, number>;
const safeReciprocal: SafeReciproca = (x) =>
  x === 0 ? left(divisionError) : right(1 / x);

我們來檢視map(decrement)和map(safeReciproca)的輸出入簽署,map(decrement)的輸出型別是Either<EvaluationError, number>,而map(SafeReciproca)的輸入也是Either<EvaluationError, number>,因此函數的合成是合法的,接下來我們檢視map(SafeReciproca)的輸出,它的輸出是Either<EvaluationError, Either<EvaluationError, number>>,也就是嵌套了兩層Either,這個問題的處理,我們會在後面Monade的部分來說明。

今日小結

今天介紹了fp-ts中負責錯誤處理的OptionEither<E>型別建構容器,由javascript number型別中NaN可以了解Option的運作邏輯。Option確保函數在合成時順利,而Either<E>除了保證函數的合成順利,更能保留錯誤訊息。

今天也詳細說明了Array模組中許多的型別安全函數,型別安全的代價是多了一層嵌套,必須了解和熟悉相對應的函數式程式設計嵌套處理機制,我們會在後續的發文中討論這些機制,今天分享的內容就到此為止,明天再見。


上一篇
Day 11. fp-ts簡介與Array
下一篇
Day 13. 隔空取物 - Applicative Functor
系列文
數學老師學函數式程式設計 - 以fp-ts啟航20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言